﻿/*
Copyright (c) 2008 William Duff

Permission is hereby granted, free of charge, to any person obtaining a copy 
of this software and associated documentation files (the "Software"), to deal 
in the Software without restriction, including without limitation the rights 
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all 
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 
SOFTWARE.
*/

using System;
using System.Collections.Generic;
using System.Text;
using System.Net;
using System.IO;
using System.Web;
using System.Xml;
using System.Threading;
using LiveUpload.YouTube.Properties;

namespace LiveUpload.YouTube
{
    /// <summary>
    /// Communicates with YouTube through API calls.
    /// </summary>
    internal class YouTubeApi
    {
        /// <summary>
        /// Constructs a new YouTubeApi object to communicate with YouTube.
        /// </summary>
        /// <param name="developerKey">
        /// Required string for some YouTube API functions.
        /// </param>
        /// <param name="clientId">
        /// Optional string to associate all API calls from this client with.
        /// </param>
        public YouTubeApi(string developerKey, string clientId)
        {
            this.developerKey = developerKey;
            this.clientId = clientId;
        }

        // Private variables.
        private string developerKey;
        private string clientId;

        /// <summary>
        /// Required string for some YouTube API functions.
        /// </summary>
        public string DeveloperKey
        {
            get { return this.developerKey; }
        }

        /// <summary>
        /// Optional string to associate all API calls from this client with.
        /// </summary>
        public string ClientId
        {
            get { return this.clientId; }
        }

        /// <summary>
        /// Sends a username and password to YouTube for authentication. If successful, an
        /// authentication token is returned.
        /// </summary>
        /// <param name="username">
        /// The name of the YouTube user.
        /// </param>
        /// <param name="password">
        /// The password associated with the YouTube user.
        /// </param>
        /// <returns>
        /// A valid token string or null.
        /// </returns>
        internal string GetUserToken(string username, string password)
        {
            if (username == null)
            {
                throw new ArgumentNullException("username");
            }
            if (password == null)
            {
                throw new ArgumentNullException("password");
            }

            // The URL we will be POSTing to for the login.
            string url = "https://www.google.com/youtube/accounts/ClientLogin";

            // HTTP POST parameters required by YouTube.
            string parameters = string.Format("Email={0}&Passwd={1}&service=youtube&source={2}",
                HttpUtility.UrlEncode(username), HttpUtility.UrlEncode(password),
                HttpUtility.UrlEncode(this.clientId));

            HttpWebResponse response = null;
            string token = null;

            try
            {
                HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
                request.Method = "POST";
                request.ContentType = "application/x-www-form-urlencoded";

                // Write the POST parameters to YouTube.
                StreamWriter writer = new StreamWriter(request.GetRequestStream());
                writer.Write(parameters);
                writer.Close();

                // Get a response.
                response = request.GetResponse() as HttpWebResponse;
            }
            catch (WebException we)
            {
                // Getting the response must have thrown an HTTP error. We can still try to get the 
                // response from the caught exception though.
                response = we.Response as HttpWebResponse;
            }

            if (response != null)
            {
                // Read the response.
                StreamReader reader = new StreamReader(response.GetResponseStream());
                string result = reader.ReadToEnd();
                reader.Close();
                response.Close();

                Dictionary<string, string> pairs = ParseAuthenticationResponse(result);

                foreach (KeyValuePair<string, string> pair in pairs)
                {
                    if (pair.Key == "Auth")
                    {
                        token = pair.Value;
                    }
                    else if (pair.Key == "Error")
                    {
                        throw new YouTubeApiException(GetAuthenticationError(pair.Value));
                    }
                }
            }

            return token;
        }

        /// <summary>
        /// Takes a string of YouTube key-value pairs and parses them into KeyValuePairs.
        /// </summary>
        /// <param name="response">
        /// A string of YouTube key-value pairs.
        /// </param>
        /// <returns>
        /// An array of YouTube KeyValuePairs.
        /// </returns>
        private Dictionary<string, string> ParseAuthenticationResponse(string response)
        {
            if (response == null)
            {
                throw new ArgumentNullException("response");
            }

            // A successful plain-text response has the format:
            // Auth=SomeYouTubeAuthToken
            // YouTubeUser=SomeYouTubeUserName
            // 
            // An unsuccessful response has the format:
            // Error=SomeErrorCode
            string[] lines = response.Split(new char[] { '\n' }, 
                StringSplitOptions.RemoveEmptyEntries);

            // We'll further split each line into a key-value pair, separating at the first equal sign.
            Dictionary<string, string> pairs = new Dictionary<string, string>();

            foreach (string line in lines)
            {
                string[] result = line.Split(new char[] { '=' }, 2);

                if (result.Length != 2)
                {
                    throw new YouTubeApiException(Resources.YouTubeResponseError);
                }

                pairs.Add(result[0], result[1]);
            }

            return pairs;
        }

        /// <summary>
        /// Returns an error message specific to the authentication error code passed in.
        /// </summary>
        /// <param name="errorCode">
        /// A string from YouTube representing the error code.
        /// </param>
        /// <returns>
        /// A human-readable error message.
        /// </returns>
        private string GetAuthenticationError(string errorCode)
        {
            switch(errorCode)
            {
                case "BadAuthentication":
                    return Resources.BadAuthenticationError;
                case "NotVerifed":
                    return Resources.NotVerifiedError;
                case "TermsNotAgreed":
                    return Resources.TermsNotAgreedError;
                case "CaptchaRequired":
                    return Resources.CaptchaRequiredError;
                case "AccountDeleted":
                    return Resources.AccountDeletedError;
                case "AccountDisabled":
                    return Resources.AccountDisabledError;
                case "ServiceDisabled":
                    return Resources.ServiceDisabledError;
                case "ServiceUnavailable":
                    return Resources.ServiceUnavailableError;
                default:
                    return Resources.UnknownError;
            }
        }

        /// <summary>
        /// Calls YouTube with the specified user's token to see if the token is still valid. This 
        /// function is static so it can easily be called by a background worker thread.
        /// </summary>
        /// <param name="user">
        /// The user we're checking.
        /// </param>
        /// <returns>
        /// true if the user token is valid and false otherwise.
        /// </returns>
        internal static bool UserTokenIsValid(Account user)
        {
            if (user == null)
            {
                throw new ArgumentNullException("user");
            }

            // Try to request the user's profile because it requires their authorization token.
            string url = "http://gdata.youtube.com/feeds/api/users/default";
            HttpWebResponse response = null;

            try
            {
                // Make the request.
                HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
                request.Headers.Add("Authorization",
                    string.Format("GoogleLogin auth={0}", user.Token));

                // Get a response.
                response = request.GetResponse() as HttpWebResponse;
            }
            catch (WebException we)
            {
                // Getting the response must have thrown an HTTP error. We can still try to get the 
                // response from the caught exception though.
                response = we.Response as HttpWebResponse;
            }

            if (response != null)
            {
                response.Close();

                if (response.StatusCode == HttpStatusCode.OK)
                {
                    // A HTTP 200 response code indicates that YouTube successfully handled an HTTP 
                    // GET request to retrieve a feed.
                    return true;
                }
            }

            return false;
        }

        /// <summary>
        /// Loads an XML file from YouTube containing assignable video categories and returns the
        /// categories as a list of strings. This function is static so it can easily be called by 
        /// a background worker thread.
        /// </summary>
        /// <returns>
        /// A list of categories as strings.
        /// </returns>
        internal static List<Category> GetCategories()
        {
            // Load the YouTube video categories XML supplied by YouTube.
            string url = 
                string.Format("http://gdata.youtube.com/schemas/2007/categories.cat?hl={0}", 
                Resources.CategoryLocalizationParameter);
            XmlDocument categoriesXml = new XmlDocument();
            categoriesXml.Load(url);

            XmlNamespaceManager nameSpaces = new XmlNamespaceManager(categoriesXml.NameTable);
            nameSpaces.AddNamespace("app", "http://www.w3.org/2007/app");
            nameSpaces.AddNamespace("atom", "http://www.w3.org/2005/Atom");
            nameSpaces.AddNamespace("yt", "http://gdata.youtube.com/schemas/2007");

            // Get all categories that are assignable.
            XmlNodeList categoryNodes =
                categoriesXml.SelectNodes("//atom:category[yt:assignable]", nameSpaces);

            // We want the 'term' attribute from each category element.
            List<Category> categories = new List<Category>();
            foreach (XmlElement categoryElement in categoryNodes)
            {
                string term = HttpUtility.HtmlDecode(categoryElement.GetAttribute("term"));
                string label = HttpUtility.HtmlDecode(categoryElement.GetAttribute("label"));
                categories.Add(new Category(term, label));
            }

            return categories;
        }

        /// <summary>
        /// Takes an input string and strips out all invalid characters and leading and 
        /// trailing white-space.
        /// </summary>
        /// <param name="input">
        /// A string that may contain invalid characters.
        /// </param>
        /// <returns>
        /// A string with invalid characters and leading and trailing white-space removed.
        /// </returns>
        internal static string SanitizeString(string input)
        {
            if (input == null)
            {
                throw new ArgumentNullException("input");
            }

            return input.Replace("<", "").Replace(">", "").Trim();
        }

        /// <summary>
        /// Takens an input string of keywords and strips out all invalid characters and leading
        /// and trailing white-space.
        /// </summary>
        /// <param name="joinedKeywords">
        /// A string of keywords.
        /// </param>
        /// <returns>
        /// A string of keywords with invalid characters and leading and trailing white-space 
        /// removed.
        /// </returns>
        internal static string SanitizeKeywords(string joinedKeywords)
        {
            if (joinedKeywords == null)
            {
                throw new ArgumentNullException("joinedKeywords");
            }

            return joinedKeywords.Replace("<", "").Replace(">", "").Replace(",", "").Trim();
        }

        /// <summary>
        /// Checks if the keywords are valid.
        /// </summary>
        /// <param name="joinedKeywords">
        /// A string of keywords delimited by spaces.
        /// </param>
        /// <returns>
        /// true if the keywords are valid and false otherwise.
        /// </returns>
        internal static bool KeywordsAreValid(string joinedKeywords)
        {
            if (joinedKeywords == null)
            {
                throw new ArgumentNullException("joinedKeywords");
            }

            string sanitizedKeywords = SanitizeKeywords(joinedKeywords);

            if (sanitizedKeywords.Length > 120)
            {
                return false;
            }

            string[] keywords = 
                sanitizedKeywords.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
            for(int i = 0; i < keywords.Length; i++)
            {
                // Keywords must be at least two characters and no more than 25.
                if (keywords[i].Length < 2 || keywords[i].Length > 25)
                {
                    return false;
                }
            }

            return true;
        }

        /// <summary>
        /// If KeywordsAreValid returned false, we call this function to find the index into the
        /// keywords that the first error appears at and the length of that keyword. Using this 
        /// data, we can highlight the offending keyword for the user.
        /// </summary>
        /// <param name="joinedKeywords">
        /// A string of keywords delimited by spaces.
        /// </param>
        /// <param name="startIndex">
        /// The starting index into the string for the offending keyword.
        /// </param>
        /// <param name="length">
        /// The length of the offending keyword.
        /// </param>
        internal static void FindKeywordError(string joinedKeywords, out int startIndex, 
            out int length)
        {
            if (joinedKeywords == null)
            {
                throw new ArgumentNullException("joinedKeywords");
            }

            int stringIndex = 0;

            // The keywords are delimited by spaces.
            string[] keywords = joinedKeywords.Split(new char[] { ' ' }, StringSplitOptions.None);
            for (int i = 0; i < keywords.Length; i++)
            {
                // There might be multiple white-space characters in a row, resulting in "empty" 
                // keywords, however we still want to add 1 to our stringIndex to account for each
                // space.
                if (keywords[i] != string.Empty)
                {
                    // Strip out all of the characters that will be removed when we upload the 
                    // video.
                    string sanitizedKeyword = SanitizeKeywords(keywords[i]);

                    // Keywords must be at least two characters and no more than 25.
                    if (sanitizedKeyword.Length < 2 || sanitizedKeyword.Length > 25)
                    {
                        // Pass back the actual index and length, not the sanitized version.
                        startIndex = stringIndex;
                        length = keywords[i].Length;
                        return;
                    }
                }

                // Move to the next word and add one for the missing space.
                stringIndex += keywords[i].Length + 1;
            }

            // Defaults.
            startIndex = 0;
            length = 0;
        }

        /// <summary>
        /// Joins a list of keywords into one string with the keywords delimited by commas.
        /// </summary>
        /// <param name="keywordSet">
        /// A list of keyword strings.
        /// </param>
        /// <returns>
        /// A single string with the list of keywords delimited by commas.
        /// </returns>
        internal static string JoinKeywords(List<string> keywordSet)
        {
            if (keywordSet == null)
            {
                throw new ArgumentNullException("keywordSet");
            }

            return string.Join(",", keywordSet.ToArray());
        }

        /// <summary>
        /// Prepares an HTTP POST request to upload a video to YouTube and packages it into an 
        /// UploadContext object.
        /// </summary>
        /// <param name="user">
        /// The YouTube user to upload with.
        /// </param>
        /// <param name="fileStream">
        /// A stream of the file to upload.
        /// </param>
        /// <param name="item">
        /// Information about the item we are uploading.
        /// </param>
        /// <param name="isPublic">
        /// True if the video should be viewable by the public and false for private.
        /// </param>
        /// <returns>
        /// An UploadContext object ready to upload a video to YouTube.
        /// </returns>
        internal UploadContext PrepareUpload(Account user, Stream fileStream, Item item, 
            bool isPublic)
        {
            if (user == null)
            {
                throw new ArgumentNullException("user");
            }
            if (fileStream == null)
            {
                throw new ArgumentNullException("fileStream");
            }
            if (item == null)
            {
                throw new ArgumentNullException("item");
            }

            // The URL we will be POSTing to.
            string uploadUrl = "http://uploads.gdata.youtube.com/feeds/api/users/default/uploads";
            
            string mimeBoundary = Guid.NewGuid().ToString();
            string keywords = YouTubeApi.JoinKeywords(item.KeywordSet);
            
            // This object holds all of the data involved in the upload process.
            UploadContext cookie = new UploadContext(uploadUrl);

            // Build the HTTP POST request by hand.
            StringBuilder postHeader = new StringBuilder();
            postHeader.AppendFormat("--{0}", mimeBoundary).AppendLine();
            postHeader.AppendLine("Content-Type: application/atom+xml; charset=UTF-8").AppendLine();
            postHeader.AppendLine("<?xml version=\"1.0\"?>");
            postHeader.Append(    "<entry xmlns=\"http://www.w3.org/2005/Atom\"");
            postHeader.Append(    " xmlns:media=\"http://search.yahoo.com/mrss/\"");
            postHeader.AppendLine(" xmlns:yt=\"http://gdata.youtube.com/schemas/2007\">");
            postHeader.AppendLine(  "<media:group>");
            postHeader.Append(      "<media:title type=\"plain\">");
            postHeader.Append(           HttpUtility.HtmlEncode(item.Title));
            postHeader.AppendLine(    "</media:title>");
            postHeader.Append(        "<media:description type=\"plain\">");
            postHeader.Append(           HttpUtility.HtmlEncode(item.Description));
            postHeader.AppendLine(    "</media:description>");
            postHeader.Append(        "<media:category");
            postHeader.Append(        " scheme=\"http://gdata.youtube.com/schemas/2007/categories.cat\">");
            postHeader.Append(           HttpUtility.HtmlEncode(item.Category));
            postHeader.AppendLine(    "</media:category>");
            postHeader.Append(        "<media:keywords>");
            postHeader.Append(           HttpUtility.HtmlEncode(keywords));
            postHeader.AppendLine(    "</media:keywords>");

            // Videos default to public. An extra tag is needed to mark a video as private.
            if (!isPublic)
            {
                postHeader.AppendLine("<yt:private/>");
            }

            postHeader.AppendLine(  "</media:group>");
            postHeader.AppendLine("</entry>");
            postHeader.AppendFormat("--{0}", mimeBoundary).AppendLine();
            postHeader.AppendLine("Content-Type: application/octet-stream");
            postHeader.AppendLine("Content-Transfer-Encoding: binary").AppendLine();

            // Store the three pieces of our HTTP POST request.
            cookie.Header = Encoding.UTF8.GetBytes(postHeader.ToString());
            cookie.DataStream = fileStream;
            cookie.Footer =
                Encoding.UTF8.GetBytes(string.Format("\r\n--{0}--\r\n", mimeBoundary));

            // HTTP POST headers required by YouTube.
            cookie.Request.Headers.Add("Authorization",
                string.Format("GoogleLogin auth={0}", user.Token));
            cookie.Request.Headers.Add("X-GData-Client", this.clientId);
            cookie.Request.Headers.Add("X-GData-Key", string.Format("key={0}", this.developerKey));
            cookie.Request.Headers.Add("Slug", item.OriginalFileName);
            cookie.Request.Headers.Add("Accept-Language", Resources.CategoryLocalizationParameter);
            cookie.Request.ContentType =
                string.Format("multipart/related; boundary=\"{0}\"", mimeBoundary);
            cookie.Request.ContentLength = cookie.Header.LongLength + cookie.DataStream.Length +
                cookie.Footer.LongLength;
            cookie.Request.KeepAlive = false;

            // Without this, the request will throw an OutOfMemory exception for large files.
            cookie.Request.AllowWriteStreamBuffering = false;

            // Start the stream we'll write our POST request to.
            cookie.RequestStream = cookie.Request.GetRequestStream();
            cookie.ChunkSize = Math.Max((int)(cookie.DataStream.Length / 100), 65536);

            return cookie;
        }

        /// <summary>
        /// Gets the response from YouTube from the upload request and parses out a URL for the
        /// video.
        /// </summary>
        /// <param name="cookie">
        /// The UploadContext for the upload with the request stream still open.
        /// </param>
        /// <returns>
        /// A URL on YouTube to edit the uploaded video.
        /// </returns>
        internal string GetUploadedVideoUrl(UploadContext cookie)
        {
            if (cookie == null)
            {
                throw new ArgumentNullException("cookie");
            }

            // Close the request stream and get a response as a string.
            string responseText = GetUploadResponse(cookie);

            if (responseText == null)
            {
                // There was no recognizable response, assume that YouTube rejected the upload.
                throw new YouTubeApiException(Resources.UploadRejectedError);
            }

            // The response is XML, so load it in as an XmlDocument.
            XmlDocument responseXml = new XmlDocument();
            responseXml.LoadXml(responseText);

            // We'll be querying for an element in the Atom namespace.
            XmlNamespaceManager nameSpaces = new XmlNamespaceManager(responseXml.NameTable);
            nameSpaces.AddNamespace("atom", "http://www.w3.org/2005/Atom");

            // We want the link with rel="alternate".
            XmlElement linkElement = responseXml.SelectSingleNode("//atom:link[@rel='alternate']", 
                nameSpaces) as XmlElement;

            return HttpUtility.UrlDecode(linkElement.GetAttribute("href"));
        }

        /// <summary>
        /// Closes the request stream and gets a response from the web service.
        /// </summary>
        /// <returns>
        /// The response as a string or null if there was no response or the response had an HTTP 
        /// status code not in the API.
        /// </returns>
        private string GetUploadResponse(UploadContext cookie)
        {
            if (cookie == null)
            {
                throw new ArgumentNullException("cookie");
            }

            HttpWebResponse response = null;

            try
            {
                cookie.RequestStream.Close();

                // Get a response.
                response = cookie.Request.GetResponse() as HttpWebResponse;
            }
            catch (WebException we)
            {
                // Getting the response must have thrown an HTTP error. We can still try to get the 
                // response from the caught exception though.
                response = we.Response as HttpWebResponse;
            }

            if (response != null)
            {
                StreamReader reader = new StreamReader(response.GetResponseStream());
                string responseText = reader.ReadToEnd();
                reader.Close();
                response.Close();

                if (response.StatusCode == HttpStatusCode.BadRequest)
                {
                    // A HTTP 400 response code indicates that a request was poorly formed or 
                    // contained invalid data.
                    throw new YouTubeApiException(GetUploadResponseError(responseText));
                }
                else if (response.StatusCode == HttpStatusCode.Created)
                {
                    // A HTTP 201 response code indicates that the the video was uploaded 
                    // successfully.
                    return responseText;
                }
            }

            // There was no response or we received a response status code not in the API.
            return null;
        }

        /// <summary>
        /// If YouTube responded to the upload with an HTTP 400 response code, we can parse the 
        /// response XML to find out the reason why the upload was rejected.
        /// </summary>
        /// <param name="responseText">
        /// The plain XML text returned by YouTube due to a failed upload.
        /// </param>
        /// <returns>
        /// A human-readable error message.
        /// </returns>
        private string GetUploadResponseError(string responseText)
        {
            if (responseText == null)
            {
                throw new ArgumentNullException("result");
            }

            string error = string.Empty;

            // The response is XML, so load it in as an XmlDocument.
            XmlDocument responseXml = new XmlDocument();
            responseXml.LoadXml(responseText);

            XmlNodeList errorNodes = responseXml.DocumentElement.SelectNodes("error");
            foreach (XmlElement errorElement in errorNodes)
            {
                XmlElement errorCode = errorElement.SelectSingleNode("code") as XmlElement;
                if (errorCode == null)
                {
                    error += Resources.UploadRejectedError + " ";
                }
                else
                {
                    switch (errorCode.InnerText)
                    {
                        case "required":
                            error += Resources.YouTubeValidationRequiredError + " ";
                            break;
                        case "deprecated":
                            error += Resources.YouTubeValidationDeprecatedError + " ";
                            break;
                        case "invalid_format":
                            error += Resources.YouTubeValidationInvalidFormatError + " ";
                            break;
                        case "invalid_value":
                            error += Resources.YouTubeValidationInvalidValueError + " ";
                            break;
                        case "invalid_character":
                            error += Resources.YouTubeValidationInvalidCharacterError + " ";
                            break;
                        case "too_long":
                            error += Resources.YouTubeValidationTooLongError + " ";
                            break;
                        case "too_short":
                            error += Resources.YouTubeValidationTooShortError + " ";
                            break;
                        case "too_many_recent_calls":
                            error += Resources.YouTubeQuotaRecentCallsError + " ";
                            break;
                        case "too_many_entries":
                            error += Resources.YouTubeQuotaEntriesError + " ";
                            break;
                        case "InvalidToken":
                            error += Resources.YouTubeAuthenticationInvalidError + " ";
                            break;
                        case "TokenExpired":
                            error += Resources.YouTubeAuthenticationExpiredError + " ";
                            break;
                        default:
                            error += Resources.UploadRejectedError + " ";
                            break;
                    }
                }
            }

            if (error == string.Empty)
            {
                // Couldn't find an error, return a generic error message.
                return Resources.UploadRejectedError;
            }
            else
            {
                return error.Trim();
            }
        }
    }
}
